A data consumer is a class or a component that binds itself to a data source. There are two types of data consumer objects: simple consumer and complex consumer. A simple consumer class or component binds one or more of its properties to the current row in the data source and so resembles an ActiveX control with multiple bindable properties. A complex consumer can bind its properties to multiple rows in the data source and resembles a grid control.
When you're transferring data from sources to consumers, consumers are passive entities. The object that actively moves data from the source to the consumer and back is the BindingCollection object.
To create a BindingCollection object, you need to reference the Microsoft Data Binding Collection library in the References dialog box. The BindingCollection's most important members are the DataSource property and the Add method. To set up a connection between a data source and a data consumer, you need to assign the data source object to the BindingCollection's DataSource property and then call the Add method for each data consumer that must be linked to the source. The complete syntax for the Add method follows:
Add(BoundObj, PropertyName, DataField, [DataFormat], [Key]) As Binding |
BoundObj is the data consumer object, PropertyName is the name of the property in the data consumer that's bound to a field of the data source, DataField is the name of the field in the source, DataFormat is an optional StdDataFormat object that affects how data is formatted during the transfer to and from the consumer, and Key is the key of the new Binding object in the collection. You can call multiple Add methods to bind multiple consumers or multiple properties of the same consumer.
A common data source is the ADO Recordset object, but you can also use a DataEnvironment object, an OLE DB Simple Provider, and any data source class or component that you've defined in code. The following code shows how you can bind two TextBox controls to fields of a database table through an ADO Recordset:
Const DBPath = "C:\Program Files\Microsoft Visual Studio\Vb98\NWind.mdb" Dim cn As New ADODB.Connection, rs As New ADODB.Recordset Dim bndcol As New BindingCollection ' Open the Recordset. cn.Open "Provider=Microsoft.Jet.OLEDB.3.51;Data Source=" & DBPATH rs.Open "Employees", cn, adOpenStatic, adLockReadOnly ' Use the Bindingcollection object to bind two TextBox controls to the ' FirstName and LastName fields of the Employees table. Set bndcol.DataSource = rs bndcol.Add txtFirstName, "Text", "FirstName", , "FirstName" bndcol.Add txtLastName, "Text", "LastName", , "LastName" |
You can control how data is formatted in the consumer by defining a StdDataFormat object, setting its Type and Format properties, and then passing it as the fourth argument of a BindingCollection's Add method, as the code below demonstrates.
Dim DateFormat As New StdDataFormat DateFormat.Type = fmtCustom DateFormat.Format = "mmmm dd, yyyy" ' One StdDataFormat object can serve multiple consumers. bndcol.Add txtBirthDate, "Text", "BirthDate", DateFormat, "BirthDate" bndcol.Add txtHireDate, "Text", "HireDate", DateFormat, "HireDate" |
If the data source exposes multiple DataMember objects, as is the case for DataEnvironment objects, you select which one is bound to data consumers by using the BindingCollection's DataMember property, exactly as you do when you bind controls to an ADO Data control.
The BindingCollection object exposes a few other properties and methods that give you more control over the binding process. The UpdateMode enumerated property determines when data is updated in the data source: For the default value, 1vbUpdateWhenPropertyChanges, the source is updated as soon as a property's value changes, whereas the value 2-vbUpdateWhenRowChanges causes the updates to the source only when the record pointer moves to another record. When the value is 0vbUsePropertyAttributes, the decision when to update the source depends on the state of the Update Immediate option in the Procedure Attributes dialog box.
Each time you execute an Add method, you actually add a Binding object to the collection. You can later query the Binding object's properties to acquire information about the binding process. Each Binding object exposes the following properties: Object (a reference to the bound data consumer), PropertyName (the name of the bound property), DataField (the field in the source), DataChanged (True if data in the consumer has been changed), DataFormat (the StdDataFormat object used to format data), and Key (the key of the Binding object in the collection). For example, you can determine whether the value in a consumer has changed by executing the following code:
Dim bind As Binding, changed As Boolean For Each bind in bndcol changed = changed Or bndcol.DataChanged Next If changed Then Debug.Print "Data has been changed" |
If you assigned a key to a Binding object, you can directly read and modify its properties:
' Set the ForeColor of the TextBox control bound to the HireDate field. bndcol("HireDate").Object.ForeColor = vbRed |
The UpdateControls method of the BindingCollection object updates all the consumers with values from the current row in the data source and resets the DataChanged properties of all Binding objects to False.
Finally, you can trap any error that occurs in the binding mechanism by using the BindingCollection's Error event. To trap this event from a BindingCollection object, you must have declared it using a WithEvents clause:
Dim WithEvents bndcol As BindingCollection Private Sub bndcol_Error(ByVal Error As Long, ByVal Description As String,_ ByVal Binding As MSBind.Binding, fCancelDisplay As Boolean) ' Deal here with binding errors. End Sub |
Error is the error code, Description is the error description, Binding is the Binding object that caused the error, and fCancelDisplay is a Boolean argument that you can set to False if you don't want to display the standard error message.
CAUTION
When binding a property of a control to a field in the data source, you should ensure that the control correctly sends the necessary notification to the binding mechanism when the property changes. For example, you can bind the Caption property of a Label or Frame control to a data source, but if you then change the value of the Caption property through code the control doesn't inform the source that the data has changed. Consequently, the new value isn't written to the database. In this case, you must force the notification yourself by using the BindingCollection object's DataChanged property.
To create a simple data consumer class, you only need to set the DataBindingBehavior attribute of the class to the value 1-vbSimpleBound in the Properties window. This setting adds two new methods that you can use from within the class module: PropertyChange and CanPropertyChange.
Implementing a simple data consumer class or component is similar to creating an ActiveX control that can be bound to a data source. In the Property Let procedures of all the bound properties, you must make sure that a property value can change by invoking the CanPropertyChange function. Then you call the PropertyChange method to inform the binding mechanism that the value has indeed changed. (Be aware that the CanPropertyChange method always returns True in Visual Basic, as I explained in the "PropertyChanged and CanPropertyChange Methods" section of Chapter 17.) The following code is taken from the demonstration program on the companion CD and shows how the sample CEmployee data consumer class implements its FirstName property:
' In the CEmployee class module Dim m_FirstName As String Property Get FirstName() As String FirstName = m_FirstName End Property Property Let FirstName(ByVal newValue As String) If newValue <> m_FirstName Then If CanPropertyChange("FirstName") Then m_FirstName = newValue PropertyChanged "FirstName" End If End If End Property |
You bind the properties of a data consumer class to the fields in a data source using a BindingCollection object. The binding operation can be performed in the client form or module (as you saw in the previous section) or inside the data consumer class itself. The latter solution is usually preferable because it encapsulates the code in the class and prevents it from being scattered in all its clients. If you follow this approach, you must provide a method that lets clients pass a data source to the class: This can be a data source class, an ADO Data control or Recordset, or a DataEnvironment object. The class can use this reference as an argument to the DataSource property of an internal BindingCollection object:
' In the CEmployee class module Private bndcol As New BindingCollection Property Get DataSource() As Object Set DataSource = bndcol.DataSource End Property Property Set DataSource(ByVal newValue As Object) Set bndcol = New BindingCollection Set bndcol.DataSource = newValue bndcol.Add Me, "FirstName", "FirstName", , "FirstName" bndcol.Add Me, "LastName", "LastName", , "LastName" bndcol.Add Me, "BirthDate", "BirthDate", , "BirthDate" End Property |
The following code shows how a client form can bind the CEmployee class to a Recordset:
Dim cn As New ADODB.Connection, rs As New ADODB.Recordset Dim employee As New CEmployee cn.Open "Provider=Microsoft.Jet.OLEDB.3.51;" _ & "Data Source="C:\Program Files\Microsoft Visual Studio\Vb98\NWind.mdb" rs.Open "Employees", cn, adOpenKeyset, adLockOptimistic Set employee.DataSource = rs |
When the program modifies a value of a bound property in the data consumer class, the corresponding field in the data source is updated, provided that the data source is updatable. But the precise moment the field is updated depends on the UpdateMode setting of the BindingCollection object. If UpdateMode is 2vbUpdateWhenRowChanges, the data source is updated only when another record becomes the current record, whereas if the setting is 1vbUpdateWhenPropertyChanges the Recordset is updated immediately. If you set UpdateMode = 0-vbUsePropertyAttributes, the data source is updated immediately only if the property is marked with the Update Immediately attribute in the Procedure Attributes dialog box.
NOTE
Even if the data source is an ADO Recordset linked to a database, updating the data source doesn't mean that the database is immediately updated, but only that the new value is assigned to the Field's Value property. A way to force the update of the underlying database is to execute the Recordset's Move method using 0 as the argument. This doesn't actually move the record pointer but flushes to the database the current contents of the Fields collection. Oddly, the Recordset's Update method doesn't work in this situation.
Here's another peculiarity in the implementation of this feature: The setting 0vbUpdateWhenPropertyChanges doesn't seem to work as the documentation states, and it doesn't immediately update the value in the Recordset. The only way to update the Recordset when a property changes is by using the setting 0vbUsePropertyAttributes and ticking the Update Immediate check box in the Procedure Attributes dialog box.
Building a complex data consumer is slightly more difficult than building a simple data consumer. The reason for the additional difficulty is mostly the lack of good and complete documentation sources. The first step in creating a complex data consumer class is to set the DataBindingBehavior to the value 2-vbComplexBound. Alternatively, you can select the Complex Data Consumer template from the template gallery when you create a new class module. In both cases, you'll find that a couple of properties—DataMember and DataSource—have been added to the class module:
Public Property Get DataSource() As DataSource End Property Public Property Set DataSource(ByVal objDataSource As DataSource) End Property Public Property Get DataMember() As DataMember End Property Public Property Let DataMember(ByVal DataMember As DataMember) End Property |
When you set DataBindingBehavior to 2-vbComplexBound in a UserControl module, Visual Basic doesn't create the templates for these two properties for you—you must do it manually.
ActiveX controls that work as complex data consumers are typically gridlike controls. They expose the DataMember and DataSource properties, but unlike ActiveX controls that behave as simple data consumers, these properties aren't Extender properties. You can't count on the automatic binding mechanism that you can specify in the Procedure Attributes dialog box, and you must implement these two properties all by yourself.
Now you need to add a few type libraries in the References dialog box. When you're building a complex data consumer, you need the Microsoft Data Sources Interfaces (Msdatsrc.tlb), the Microsoft Data Binding Collection (msbind.dll), and, of course, the Microsoft ActiveX Data Objects 2.0 (or 2.1) Library. The first of these libraries exposes the DataSource interface, which is supported by all the objects that can work as data sources, such as the ADO Recordset, the ADO Data control, and the DataEnvironment object.
On the companion CD, you'll find the complete source code for the ProductGrid ActiveX control, shown in Figure 18-4. This ActiveX control builds on a ListView control to give you a custom view of the Products table of the NWind.mdb database. I used the ActiveX Control Interface Wizard to create most of the properties and events of this control, such as Font, BackColor, ForeColor, CheckBoxes, FullRowSelection, and all the mouse and keyboards events. The only routines I had to write manually are those that implement the binding mechanism. The declaration section of the ProductGrid module contains the following private variables:
Private WithEvents rs As ADODB.Recordset Private bndcol As New BindingCollection Private m_DataMember As String |
Implementing the DataMember property is as easy as creating a wrapper around the private m_DataMember string variable:
Public Property Get DataMember() As String DataMember = m_DataMember End Property Public Property Let DataMember(ByVal newValue As String) m_DataMember = newValue End Property |
The Property Let DataSource procedure is where the binding process actually takes place. This procedure is called when the class or the control is bound to its data source. The binding can be done explicitly via code, or it can be done implicitly at form loading if you set the DataSource property in the Properties window of an ActiveX control that works as a complex data consumer. This is the implementation of the DataSource property for the CustomerGrid control:
Public Property Get DataSource() As DataSource ' Simply delegate to the Recordset's DataMember property. If Not (rs Is Nothing) Then Set DataSource = rs.DataSource End If End Property Public Property Set DataSource(ByVal newValue As DataSource) If Not Ambient.UserMode Then Exit Property If Not (rs Is Nothing) Then ' If the new value equals the old one, exit right now. If rs.DataSource Is newValue Then Exit Property If (newValue Is Nothing) Then ' The Recordset is being closed. (The program is shutting ' down.) Flush the current record. Select Case rs.LockType Case adLockBatchOptimistic rs.UpdateBatch Case adLockOptimistic, adLockPessimistic rs.Update Case Else End Select End If End If If Not (newValue Is Nothing) Then Set rs = New ADODB.Recordset ' Re-create the Recordset. rs.DataMember = m_DataMember Set rs.DataSource = newValue Refresh ' Reload all data. End If End Property |
Figure 18-4. The grid on this form is an instance of the ProductGrid ActiveX control.
Notice that the previous routines don't include any reference to the UserControl's constituent controls. In fact, you can reuse them in nearly every class or component without changing a single line of code. The code specific to each particular component is located in the Refresh method:
Sub Refresh() ' Exit if in design mode. If Not Ambient.UserMode Then Exit Sub ' Clear the ListView, and exit if the Recordset is empty or closed. ListView1.ListItems.Clear If rs Is Nothing Then Exit Sub If rs.State <> adStateOpen Then Exit Sub ' Move to the first record, but remember the current position. Dim Bookmark As Variant, FldName As Variant Bookmark = rs.Bookmark rs.MoveFirst ' Load the data from the Recordset into the ListView. Do Until rs.EOF With ListView1.ListItems.Add(, , rs("ProductName")) .ListSubItems.Add , , rs("UnitPrice") .ListSubItems.Add , , rs("UnitsInStock") .ListSubItems.Add , , rs("UnitsOnOrder") ' Remember the Bookmark of this record. .Tag = rs.Bookmark End With rs.MoveNext Loop ' Restore the pointer to the current record. rs.Bookmark = Bookmark ' Bind the properties to the Recordset. Set bndcol = New BindingCollection bndcol.DataMember = m_DataMember Set bndcol.DataSource = rs For Each FldName In Array("ProductName", "UnitPrice", "UnitsInStock", _ "UnitsOnOrder") bndcol.Add Me, FldName, FldName Next End Sub |
This is a rather simple implementation of a data-aware grid ActiveX control based on the ListView common control. A more sophisticated control would probably avoid loading the entire Recordset all at once in the ListView and would instead exploit a buffering algorithm to improve performance and reduce memory consumption.
A complex data consumer has to do a couple of things to meet the user's expectations. First, it should change the current record when the user clicks on another grid row. Second, it should highlight a record when it becomes the current record. In the ProductGrid control, the first goal is met by code in the ListView's ItemClick event; this code exploits the fact that the control stores the value of the Bookmark property for each record in the Recordset in the Tag property of all the ItemList elements:
Private Sub ListView1_ItemClick(ByVal Item As MSComctlLib.ListItem) rs.Bookmark = Item.Tag End Sub |
To highlight a different row in the ListView control when it becomes the current record, you need to write code in the Recordset's MoveComplete event:
Private Sub rs_MoveComplete(ByVal adReason As ADODB.EventReasonEnum, _ ByVal pError As ADODB.Error, adStatus As ADODB.EventStatusEnum, _ ByVal pRecordset As ADODB.Recordset) Dim Item As ListItem ' Exit if in a BOF or EOF condition. If rs.EOF Or rs.BOF Then Exit Sub ' Highlight the item corresponding to the current record. For Each Item In ListView1.ListItems If Item.Tag = rs.Bookmark Then Set ListView1.SelectedItem = Item Exit For End If Next ' Ensure that the item is visible. If Not (ListView1.SelectedItem Is Nothing) Then ListView1.SelectedItem.EnsureVisible End If ListView1.Refresh End Sub |
The source code for the demonstration program exploits a technique that avoids running the code in the MoveComplete event procedure if the move was caused by an action inside the UserControl (in which case, the control already knows which row in the grid should be highlighted).
You can use the ProductGrid ActiveX control exactly as you would use a DataGrid or another data-aware grid control. I found, however, that the binding mechanism still has some rough edges. For example, if you refresh an ADO Data control, a complex data consumer authored in Visual Basic doesn't seem to get any notification. Therefore, if you need to change one or more properties in an ADO Data control and then execute its Refresh method, you also have to reassign the ADO Data control to the DataSource property of the ProductGrid control:
Adodc1.ConnectionString = "Provider=Microsoft.Jet.OLEDB.3.51;" _ & "Data Source=C:\Program Files\Microsoft VisualStudio\Vb98\NWind.mdb" Adodc1.Refresh Set ProductGrid1.DataSource = Adodc1 |